Skip to content

[RNE Rewrite] feat(fetcher): keep iOS downloads running while the app is backgrounded - #1369

Open
msluszniak wants to merge 2 commits into
rne-rewritefrom
@ms/fetcher-ios-background
Open

[RNE Rewrite] feat(fetcher): keep iOS downloads running while the app is backgrounded#1369
msluszniak wants to merge 2 commits into
rne-rewritefrom
@ms/fetcher-ios-background

Conversation

@msluszniak

@msluszniak msluszniak commented Aug 18, 2026

Copy link
Copy Markdown
Member

Description

This PR adds background download functionality for iOS. Previously, only Android had out-of-the-box download functionality.

Introduces a breaking change?

  • Yes
  • No

Type of change

  • Bug fix (change which fixes an issue)
  • New feature (change which adds functionality)
  • Documentation update (improves or adds clarity to existing documentation)
  • Other (chores, tests, code style improvements etc.)

Tested on

  • iOS
  • Android

Testing instructions

Point download() at a few hundred MB served from a machine that logs what it actually sends, so the transfer can be observed independently of the app. Then:

  1. Survives suspension. Start the download, wait for progress, then background the app (launching any other app is enough). The server should keep receiving reads for as long as the transfer needs, and the app should report completion when it comes back. On main the connection dies about a second after the app is suspended.
  2. Resumes after a cancel. Start the download, abort it via signal partway, then call download() for the same URL again. The second attempt should report progress starting near the abort point, and the server should see a Range: bytes=N- request rather than a second full one.
  3. forceDownload really restarts. Abort partway, then download(..., { forceDownload: true }). The server should see a request with no Range.

Android is unchanged; the three new spec methods reject there as iOS-only.

Screenshots

Measured on an iPhone 16 (iOS 26.5), server-side log:

suspended at 12.8%      -> SENT continues to 311 MB of 314 MB, +129 s, no DROPPED
                        -> app reports p=1.0000, DONE
control (old path)      -> DROPPED after 42,991,616 bytes, +1 s
abort at p=0.0793       -> GET /medium.bin Range=bytes=25134704-   (= 0.0799)
                        -> app reports resumed at p=0.0799

Related issues

Part of #1366. Stacked on #1368, which is stacked on #1365.

Checklist

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings

Additional notes

A transfer that finishes while the app is killed needs the host app to forward application:handleEventsForBackgroundURLSession:completionHandler:. Surviving suspension works without it. I left that as a follow-up rather than adding an AppDelegate integration here, but say the word if it belongs in this PR.

The download speed part of #1366 is still to come, in its own PR.

@msluszniak msluszniak self-assigned this Aug 18, 2026
@msluszniak msluszniak added the bug fix PRs that are fixing bugs label Aug 18, 2026
msluszniak added a commit that referenced this pull request Aug 26, 2026
Benchmarking the fetcher for #1366 did not reproduce a throughput regression,
but it did find a fixed cost in front of every transfer.

`download()` measured each URL with a HEAD and awaited ALL of them before
starting ANY transfer. So a download paid a round trip before its first byte,
and against Hugging Face two, since a model URL answers with a 302 to a CDN.
Worse, the wait was collective: one slow HEAD held up every file in the config.
That is not theoretical, it was hit while testing against huggingface.co over a
weak link, where a hung HEAD left a multi-file download sitting at 0% for five
minutes with nothing on the wire.

The length is only needed AFTER a transfer, to check it arrived whole, and for
progress weighting, which already corrects itself from the transfer's own
reported length. So the lookup is now started and not awaited: transfers begin
immediately and the HEAD resolves alongside them. `expectedBytes` accordingly
became a value or a promise for one, awaited at the point of use.

The one place that still needs the length up front is iOS deciding whether an
already-staged `.partial` is complete, and that is now guarded on the file
existing at all, which in the ordinary case it does not.

Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the
`download()` call to the first byte:

  before  2.4s     (server logs HEAD, then GET)
  after   0.2s     (server logs GET, then HEAD alongside it)

What the benchmark did NOT find, recorded so it isn't re-litigated:

* Android is not leaving throughput on the table. Over the same link, the
  system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s
  ceiling measured with `curl` on the device itself, so it runs at ~94% of what
  the device can do.
* On iOS both the old in-process path and the background session from #1369
  ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are
  indistinguishable here. A throughput regression that only shows on a fast
  link is NOT ruled out; it would need re-measuring somewhere with real
  bandwidth.

Part of #1366.
@msluszniak
msluszniak force-pushed the @ms/fetcher-ios-background branch from 5a9db33 to 5bee415 Compare August 26, 2026 13:05
msluszniak added a commit that referenced this pull request Aug 26, 2026
Benchmarking the fetcher for #1366 did not reproduce a throughput regression,
but it did find a fixed cost in front of every transfer.

`download()` measured each URL with a HEAD and awaited ALL of them before
starting ANY transfer. So a download paid a round trip before its first byte,
and against Hugging Face two, since a model URL answers with a 302 to a CDN.
Worse, the wait was collective: one slow HEAD held up every file in the config.
That is not theoretical, it was hit while testing against huggingface.co over a
weak link, where a hung HEAD left a multi-file download sitting at 0% for five
minutes with nothing on the wire.

The length is only needed AFTER a transfer, to check it arrived whole, and for
progress weighting, which already corrects itself from the transfer's own
reported length. So the lookup is now started and not awaited: transfers begin
immediately and the HEAD resolves alongside them. `expectedBytes` accordingly
became a value or a promise for one, awaited at the point of use.

The one place that still needs the length up front is iOS deciding whether an
already-staged `.partial` is complete, and that is now guarded on the file
existing at all, which in the ordinary case it does not.

Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the
`download()` call to the first byte:

  before  2.4s     (server logs HEAD, then GET)
  after   0.2s     (server logs GET, then HEAD alongside it)

What the benchmark did NOT find, recorded so it isn't re-litigated:

* Android is not leaving throughput on the table. Over the same link, the
  system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s
  ceiling measured with `curl` on the device itself, so it runs at ~94% of what
  the device can do.
* On iOS both the old in-process path and the background session from #1369
  ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are
  indistinguishable here. A throughput regression that only shows on a fast
  link is NOT ruled out; it would need re-measuring somewhere with real
  bandwidth.

Part of #1366.
@msluszniak
msluszniak force-pushed the @ms/fetcher-ios-background branch 2 times, most recently from aec5ec0 to b2f40a2 Compare August 26, 2026 13:27
msluszniak added a commit that referenced this pull request Aug 26, 2026
Benchmarking the fetcher for #1366 did not reproduce a throughput regression,
but it did find a fixed cost in front of every transfer.

`download()` measured each URL with a HEAD and awaited ALL of them before
starting ANY transfer. So a download paid a round trip before its first byte,
and against Hugging Face two, since a model URL answers with a 302 to a CDN.
Worse, the wait was collective: one slow HEAD held up every file in the config.
That is not theoretical, it was hit while testing against huggingface.co over a
weak link, where a hung HEAD left a multi-file download sitting at 0% for five
minutes with nothing on the wire.

The length is only needed AFTER a transfer, to check it arrived whole, and for
progress weighting, which already corrects itself from the transfer's own
reported length. So the lookup is now started and not awaited: transfers begin
immediately and the HEAD resolves alongside them. `expectedBytes` accordingly
became a value or a promise for one, awaited at the point of use.

The one place that still needs the length up front is iOS deciding whether an
already-staged `.partial` is complete, and that is now guarded on the file
existing at all, which in the ordinary case it does not.

Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the
`download()` call to the first byte:

  before  2.4s     (server logs HEAD, then GET)
  after   0.2s     (server logs GET, then HEAD alongside it)

What the benchmark did NOT find, recorded so it isn't re-litigated:

* Android is not leaving throughput on the table. Over the same link, the
  system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s
  ceiling measured with `curl` on the device itself, so it runs at ~94% of what
  the device can do.
* On iOS both the old in-process path and the background session from #1369
  ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are
  indistinguishable here. A throughput regression that only shows on a fast
  link is NOT ruled out; it would need re-measuring somewhere with real
  bandwidth.

Part of #1366.

@barhanc barhanc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not add native code to patch a missing feature in fetching library. The fetcher inside our lib is more like a helper addition, not a core functionality and there are already react-native libs that only do background fetching, so let's keep our helper simple. If a user needs more than that they can just use one of these libs for fetching.

Base automatically changed from @ms/fetcher-progress to rne-rewrite August 27, 2026 07:03
msluszniak added a commit that referenced this pull request Aug 27, 2026
Benchmarking the fetcher for #1366 did not reproduce a throughput regression,
but it did find a fixed cost in front of every transfer.

`download()` measured each URL with a HEAD and awaited ALL of them before
starting ANY transfer. So a download paid a round trip before its first byte,
and against Hugging Face two, since a model URL answers with a 302 to a CDN.
Worse, the wait was collective: one slow HEAD held up every file in the config.
That is not theoretical, it was hit while testing against huggingface.co over a
weak link, where a hung HEAD left a multi-file download sitting at 0% for five
minutes with nothing on the wire.

The length is only needed AFTER a transfer, to check it arrived whole, and for
progress weighting, which already corrects itself from the transfer's own
reported length. So the lookup is now started and not awaited: transfers begin
immediately and the HEAD resolves alongside them. `expectedBytes` accordingly
became a value or a promise for one, awaited at the point of use.

The one place that still needs the length up front is iOS deciding whether an
already-staged `.partial` is complete, and that is now guarded on the file
existing at all, which in the ordinary case it does not.

Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the
`download()` call to the first byte:

  before  2.4s     (server logs HEAD, then GET)
  after   0.2s     (server logs GET, then HEAD alongside it)

What the benchmark did NOT find, recorded so it isn't re-litigated:

* Android is not leaving throughput on the table. Over the same link, the
  system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s
  ceiling measured with `curl` on the device itself, so it runs at ~94% of what
  the device can do.
* On iOS both the old in-process path and the background session from #1369
  ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are
  indistinguishable here. A throughput regression that only shows on a fast
  link is NOT ruled out; it would need re-measuring somewhere with real
  bandwidth.

Part of #1366.
@msluszniak
msluszniak force-pushed the @ms/fetcher-ios-background branch from b2f40a2 to a214fcd Compare August 27, 2026 07:03
@msluszniak

Copy link
Copy Markdown
Member Author

Let's not add native code to patch a missing feature in fetching library. The fetcher inside our lib is more like a helper addition, not a core functionality and there are already react-native libs that only do background fetching, so let's keep our helper simple. If a user needs more than that they can just use one of these libs for fetching.

You're right, I'll change it to make it easy to plug an optional dependency with background download here.

Android downloads run on the system DownloadManager, so they continue while
the app is backgrounded or killed. iOS streamed in-process through blob-util,
which does not. Measured on device against a 314 MB file: suspending the app
tore the connection down ONE SECOND later, 43 MB in, and the server logged the
drop. For a 1-3 GB model that is the difference between a download finishing
and never finishing, and it is one of the ways a partial file appeared in the
first place.

iOS only offers background transfers through a background NSURLSession, so
this adds one, owned by the package rather than borrowed from blob-util.

The awkward part is that a background session only does DOWNLOAD tasks, which
stage into their own private file and hand it over whole at the end. There is
no partially written file to append to, so the Range-based `.partial` resume
from #1365 cannot be kept alongside it. Resume instead goes through
NSURLSession's own resume data, which is persisted next to the destination, so
it survives the app being killed and not merely suspended. Cancelling still
keeps the bytes: `cancelByProducingResumeData:` rather than a plain `cancel`.

Details that were not optional:

* The session identifier is fixed rather than per-request, because that is what
  lets iOS hand a finished transfer back to a relaunched app. It also means the
  session outlives the JS: `startDownload` therefore adopts a task already
  running for the same destination instead of starting a second one, and
  `resetDownload` (which backs `forceDownload`) cancels it, or "download it
  again" would attach to the attempt it is meant to replace.
* `discretionary` is set to NO. Background sessions otherwise let iOS defer a
  transfer on power or network grounds, which is wrong for a download the user
  just asked for.
* `cancelByProducingResumeData:` returns its data asynchronously. Without
  waiting for it, a download restarted straight after an abort read the resume
  file before it was written and started from zero; observed exactly that, then
  fixed it by having a start wait on an in-flight cancel for the same file.
* A background download task never fires `didReceiveResponse`, so there is no
  mid-transfer status any more. The completeness check from #1365 is unaffected:
  it compares the bytes on disk against the expected length.

Verified on an iPhone 16, against a server logging what it actually sent:

* Suspended at 12.8%, the transfer kept going for 129 s and 311 of 314 MB with
  no drop, then delivered completion and resolved. The control run, on the old
  path, dropped one second after suspension.
* Aborted at p=0.0793 and downloaded again: resumed at p=0.0799, and the server
  saw `Range: bytes=25134704-` (exactly 0.0799 of the file) rather than a
  second full request.

Part of #1366.

Known limitation: a transfer that finishes while the app is KILLED needs the
host app to forward `application:handleEventsForBackgroundURLSession:`. Without
it, surviving suspension still works. Left as a follow-up.
Replaces the background NSURLSession this branch added natively with an optional
dependency on `@kesha-antonov/react-native-background-downloader`. The problem is
unchanged: an in-process transfer on iOS dies with the app (measured on device,
suspension tore the connection down ONE SECOND later, 43 MB into a 314 MB file),
and only a background session keeps going. What changes is who owns that session.

The fetcher is a helper on top of the models API, not the part of this library
that should be growing native code to patch a gap in a fetching library, and
there are React Native packages that do nothing but background transfers. So RNE
uses one when the app has it installed, and keeps the in-process path when it
doesn't. The package is an optional peer dependency, so apps that don't want it
pull in nothing. Native code added earlier on this branch is removed:
RnExecutorchDownloader.{h,mm}, the three TurboModule methods, the progress
event, and the Android stubs that only existed because the spec was shared.

How the optional part works:

* `loadBackgroundDownloader()` requires the package inside a try/catch, which is
  Metro's own escape hatch for optional dependencies
  (`resolver.allowOptionalDependencies`, on in both React Native's and Expo's
  Metro config): an uninstalled module is left unresolved rather than failing the
  bundle, and the throw lands in the catch. Verified by bundling apps/nlp without
  the package installed: the dependency is emitted as `null` and the bundle
  builds.
* Having the JS is not the same as having the native module, so the probe also
  checks that `RNBackgroundDownloader` is linked. Expo Go and a missing
  `pod install` fall back rather than throwing on first use.
* The API is typed structurally in `backgroundDownloader.ts`, so nothing needs
  the package present to typecheck, and a version that does not match that shape
  is treated as absent.

The background path keeps the semantics the native version had, for the same
reason: iOS only offers background transfers as DOWNLOAD tasks, which stage into
their own file and hand it over whole at the end, so there is no partially
written file to append to and resume goes through NSURLSession resume data
rather than an HTTP Range request. An abort therefore PAUSES the task, which
keeps the fetched bytes where a stop would discard them, and waits for the resume
data to be written; the next download adopts that task rather than starting a
second one. `forceDownload` clears that state too, or "download it again" would
quietly continue the attempt it is meant to replace.

Two things also come for free with the library that the native version did not
have: its config plugin adds the AppDelegate
`handleEventsForBackgroundURLSession:` forwarding, which is what a transfer
finishing while the app is KILLED needs, and its resume data is persisted across
launches.

The completeness check is unchanged: the bytes on disk are compared against the
expected length before the file is promoted into the cache.

Part of #1366.
@msluszniak
msluszniak force-pushed the @ms/fetcher-ios-background branch from 7aa4e53 to 6244556 Compare August 27, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug fix PRs that are fixing bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants